Skip to content

feat: calsync v1 implementation#1

Merged
okash1n merged 47 commits into
mainfrom
feat/v1-implementation
Jul 3, 2026
Merged

feat: calsync v1 implementation#1
okash1n merged 47 commits into
mainfrom
feat/v1-implementation

Conversation

@okash1n

@okash1n okash1n commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

概要

calsync v1 の全実装(47 commits)。複数の Google / Microsoft 365 カレンダーを差分ポーリングで相互監視し、Busy 予定を他の全アカウントへ「予定あり」ブロッカーとしてミラーするセルフホスト型デーモン。

設計書: docs/superpowers/specs/2026-07-03-calsync-design.md / 実装計画: docs/superpowers/plans/2026-07-03-calsync-v1.md(いずれも main に含まれるため本 PR の diff は実装のみ)

主な内容

  • 同期エンジン(internal/engine): 差分処理の決定則(仕様 6.1)、mappings 一次判定+拡張プロパティタグ二次判定のループ防止、intent-first の冪等ブロッカー作成、iCalUID による同一会議の重複抑止と昇格、set-difference リコンサイル(カーソル失効時も mappings 非破壊)、日次リコンサイル(孤児収容・pending 解決・手動削除ブロッカーの復元)、reauth_required のアカウント単位分離
  • プロバイダ(internal/provider/{google,microsoft}): Google syncToken 差分(併用禁止パラメータ遵守・410 リカバリ・409 蘇生)/ Graph calendarView delta(ImmutableId 統一・maxpagesize 回避+サーキットブレーカー・@removed ノイズ耐性・OData %20 エンコード)。認証失効は ErrAuthExpired に正規化
  • 認証(internal/auth): ループバック + PKCE(Google/MS 共通)、Device Code(MS)、refresh token ローテーション追従の永続化
  • 状態管理(internal/store): SQLite(WAL・flock 多重起動防止・status/doctor 用の読み取り専用オープン)
  • CLI(cmd/calsync): run / sync --once / reconcile / status / doctor / auth add・list / accounts remove(--force)
  • 配布: multi-stage Dockerfile(distroless)、docker-compose 例、README(GCP「In production」必須手順・Entra 設定・Docker 認証手順・プライバシー注記)

実行済みチェック

  • go test ./... -race -count=1 全10パッケージ PASS / go vet / gofmt -l クリーン / CGO_ENABLED=0 go build ./... 成功 / docker compose config -q 成功
  • 21タスクすべてでタスク単位レビュー実施。ブランチ全体の最終レビューで Critical 3件・Important 3件を検出し修正、再レビューで全所見解消を確認

リスク・未検証事項

  • 実 Google / Microsoft API への疎通は未実施(すべて httptest モック)。リリース前に仕様書 15 章のスパイク11項目の消化が必要(特に: OAuth スコープ実疎通、Google 409 での cancelled ブロッカー蘇生、Graph 終日イベントの UTC 変換有無)
  • docker build / docker run は Docker デーモン不在のため未実行(構文・ビルドの代替確認のみ)
  • Graph の Retry-After は整数秒のみ対応(HTTP-date 形式はフォールバック)。Dockerfile は root 実行(nonroot 化は今後の改善)

okash1n added 30 commits July 3, 2026 17:05
… resolution

Add Engine.Reconcile covering the daily self-healing cycle: FullResync
for every watched calendar, resolution of mappings left pending across
a crash, adoption/cleanup of tagged orphan blockers via ListBlockers,
and re-evaluation of suppressed mappings whose duplicate real event has
since disappeared. Per-calendar/account errors are joined so one
account's failure does not block the rest.

Also adds a regression test verifying that a failed CreateBlocker during
pending resolution leaves the mapping pending (not active) and that it
recovers cleanly on the next Reconcile once the failure clears.
Prevent FullResync's own upsertBlockers retry from pre-resolving the
pending mapping under test, so the CreateBlocker-failure assertions
exercise resolvePending exclusively rather than a mix of both paths.
…mapping and normalization

Implements internal/provider/google.Client.Changes: full sync
(timeMin/timeMax/singleEvents) vs incremental sync (syncToken only),
full-page-completion-only nextSyncToken handling, 410 GONE ->
provider.ErrCursorInvalid, 403/429 usageLimits exponential
backoff+jitter retry (max 5), and event normalization (cancelled,
transparency, self-declined attendee, extendedProperties.private
calsync-origin, UTC dateTime, all-day date).

Promotes google.golang.org/api to a direct dependency (it was already
present as indirect). Doing so pulls in the transitive deps its
calendar/v3, googleapi and option subpackages require, and forces the
go directive from 1.25.0 to 1.25.8 because google.golang.org/api
v0.287.0's own go.mod declares `go 1.25.8`; this is a hard MVS
requirement with no `go get`/toolchain-directive workaround at the
pinned version, so the go directive bump could not be avoided.
Google provider only mapped 410 to ErrCursorInvalid; invalid_grant /
interaction_required (RFC6749 token errors, arriving wrapped in
*url.Error via oauth2.Transport) and googleapi 401 never surfaced as
provider.ErrAuthExpired, so the engine's reauth_required transition
(spec 9.3) never triggered on real token revocation.

Add provider.NormalizeAuthErr as a provider-agnostic helper (usable by
the future microsoft provider) that unwraps oauth2.RetrieveError via
errors.As and wraps invalid_grant/interaction_required in
ErrAuthExpired, preserving the original error via %w. google-specific
401 detection stays in the google package and delegates the rest to
NormalizeAuthErr, keeping googleapi out of provider/autherr.go.
…ne lookup

Implements CreateBlocker/UpdateBlocker/DeleteBlocker/ListBlockers/
GetCalendarTimezone, completing provider.Provider for the google
package. CreateBlocker uses a client-generated idempotent event ID
and reconciles 409 conflicts by fetching the existing event.
DeleteBlocker treats 404/410 as success. All error paths through
doWithRetry are normalized via normalizeAuthErr so 401/invalid_grant
map to provider.ErrAuthExpired, consistent with Changes (Task 14).
…andling

Implements internal/provider/microsoft: client base (microsoft.go) with
Prefer: IdType="ImmutableId" headers, 429/5xx retry with injectable sleep,
and Changes (delta.go) covering calendarView delta paging, @removed/
isCancelled -> Deleted, syncStateNotFound/410 -> ErrCursorInvalid, and a
50-page/identical-page circuit breaker.

Also normalizes Graph HTTP 401 and oauth2.RetrieveError
(invalid_grant/interaction_required) to provider.ErrAuthExpired via the
shared doRead path, per Task 14 review follow-up.
url.Values.Encode() encodes spaces as "+", but Microsoft Graph's OData
parser rejects "+" as a space substitute inside $filter/$expand (known
Graph behavior). The extended-property id contains spaces, so
findBlockerByOriginTag, ListBlockers and getBlockerRecord could fail
against the real API even though httptest-based tests passed (Go's
net/url symmetrically decodes "+" back to space via Query()).

Add encodeQuery() to rewrite the encoded "+" to "%20" and use it at
all three call sites. Add RawQuery-based tests (Query() cannot detect
the bug) confirming no literal "+" reaches the wire.
okash1n added 17 commits July 3, 2026 20:00
The 409 conflict path in CreateBlocker unconditionally returned the ID
found by events.get, even when that event was cancelled. Google keeps
deleted events around in cancelled state and returns 409 again for the
same deterministic idempotent ID, so a busy->free->busy delete+recreate
cycle would adopt an invisible cancelled event into the active mapping
and the blocker would never reappear.

Now the 409 path checks events.get's status: if cancelled, it revives
the event via events.update using the same body events.insert would
have sent (minus the ID), sharing that body construction between the
two call sites.
Spec section 8.4 requires that a blocker be recreated whenever its
origin event is still alive, even if someone deleted the blocker by
hand. No reconcile phase implemented this: upsertBlockers' time_hash
match case never calls the provider, so a manually deleted blocker
whose origin is unchanged was never detected by normal sync.

Add a restoreMissingBlockers phase, run after adoptOrphans and before
reevaluateSuppressed. For each account it lists real blockers to build
the set of live IDs, then re-creates via the existing
createFromMapping helper any active mapping whose BlockerEventID is
missing from that set, using the cached origin event. If the origin
isn't cached, it's skipped and left for the next FullResync.
OriginTag encodes as "<account_id>:<event_id>", and parseOriginTag
splits on the first colon. An account id containing a colon would make
adoption misparse the tag and could cause it to delete a legitimate
blocker it mistakes for an orphan. Reject such ids at config load time.
…zily

The calendar.events OAuth scope cannot call calendars.get (403 in real
environments), so GetCalendarTimezone now reads the timeZone field of
the events.list response envelope (maxResults=1), which the events
scope can access. Microsoft's /me/mailboxSettings/timeZone requires
MailboxSettings.Read, so that delegated scope is added to the OAuth
config and documented in the Entra setup steps in the README.

The engine also only fetches the target timezone for all-day blockers
now: timed blockers are written in fixed UTC on both providers, so
fetching a timezone for every event was a wasted API call and an
unnecessary scope dependency.
Map HTTP 404 from events.patch (Google) and PATCH /me/events (Graph)
to provider.ErrNotFound. When the engine's time-hash-mismatch branch
receives ErrNotFound, the blocker was deleted out-of-band (e.g. by
hand), so upsertBlockers now falls back to createFromMapping, which
re-creates it through the usual pending -> CreateBlocker -> active
flow instead of failing the whole sync cycle.

Complements the restoreMissingBlockers reconcile phase (0efba52): that
phase covers unchanged origins once a day, this fallback covers the
update path immediately.
A target account's expired token used to surface as a bare
ErrAuthExpired from the origin account's sync, so scheduler.tick
blamed the origin, marked it reauth_required and stopped its polling
entirely (spec 9.3 violation: an expired account must only stop
itself).

Introduce TargetAuthError{AccountID, Err}. Blocker writes to a target
(upsertBlockers, deleteBlockersForOrigin, createFromMapping) wrap
ErrAuthExpired in it, skip just that target, keep distributing to the
remaining targets and return the collected errors via errors.Join.
SyncCalendar and FullResync treat events whose failures consist only
of TargetAuthErrors as non-fatal: processing continues and the cursor
still advances (the pending mapping plus reconcile backfill the
expired target after reauth).

scheduler.tick walks the error tree, records reauth_required on every
calendar of each expired *target* account and skips it on later ticks;
the origin's own expiry (ErrAuthExpired outside any TargetAuthError)
is still attributed to the origin as before.
…g blocker

The 409 fallback resurrection (58edfac) updated the cancelled event
without a status field, relying on events.update resetting omitted
fields to their defaults. Send status: "confirmed" explicitly so the
revival does not depend on that behavior.
…rification

The all-day date extraction assumes Graph delta returns the local
calendar date; whether the real API converts it to UTC (shifting the
date) must be verified against the spike list in spec chapter 15.
resolvePending, adoptOrphans, restoreMissingBlockers and
reevaluateSuppressed returned on the first error, so a single failing
account or mapping aborted the whole phase for every other account
(spec 10: one calendar's failure must not stop the cycle). They now
collect errors per account/row with errors.Join and keep going, the
same pattern Reconcile already uses for the FullResync loop. The
adoptOrphans loop body moves into an adoptOrphan helper so one
blocker's failure skips just that record.
busy_show_as now only accepts the Graph freeBusyStatus enum (free,
tentative, busy, oof, workingElsewhere, unknown); a typo used to be
silently treated as "never busy". sync --once records a successful
per-calendar sync via SetCalendarError(ref, "") so calsync status
reflects one-shot syncs the same way the daemon tick does.
Copilot AI review requested due to automatic review settings July 3, 2026 12:35
@okash1n okash1n merged commit 8b60203 into main Jul 3, 2026
1 check passed
@okash1n okash1n deleted the feat/v1-implementation branch July 3, 2026 12:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

calsync v1 の同期デーモン実装として、Google / Microsoft 365 カレンダーを差分ポーリングで監視し、Busy 予定を他アカウントへ「予定あり」ブロッカーとしてミラーする一連の基盤(エンジン/ストア/認証/CLI/配布)を追加する PR です。

Changes:

  • SQLite(WAL+flock) を用いた状態管理(events/mappings/calendars)と、同期/リコンサイルに必要な CRUD・検索APIを追加
  • Google syncToken / Microsoft Graph deltaLink による差分取得、ブロッカー作成/更新/削除/列挙、認証失効の正規化を含むプロバイダ実装を追加
  • OAuth(Loopback+PKCE / Device Code)・トークン永続化、CLI(run/sync/reconcile/status/doctor/auth/accounts)および Docker 配布・README を追加

Reviewed changes

Copilot reviewed 56 out of 57 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
README.md セットアップ/運用手順、制約、CLI、トラブルシュートを追加
internal/store/store.go SQLite ストア(Open/ReadOnly/Close)と calendars 管理を実装
internal/store/store_test.go store Open/ReadOnly と calendars 操作のテストを追加
internal/store/mappings.go mappings の upsert/取得/列挙/判定APIを追加
internal/store/mappings_test.go mappings API のテストを追加
internal/store/events.go events キャッシュの upsert/取得/検索APIを追加
internal/store/events_test.go events API のテストを追加
internal/provider/provider.go Provider インターフェイスと共通エラーを定義
internal/provider/microsoft/microsoft.go Graph クライアント基盤(do/doRead)、Graph 時刻処理を追加
internal/provider/microsoft/delta.go /calendarView/delta による差分取得(カーソル/ループ検知)を実装
internal/provider/microsoft/blockers.go Graph ブロッカー CRUD/ListBlockers/TZ取得を実装
internal/provider/google/google.go Google Calendar クライアント基盤(リトライ/認証失効正規化)を追加
internal/provider/google/changes.go events.list による差分取得(syncToken/410処理)を実装
internal/provider/google/blockers.go Google ブロッカー CRUD/ListBlockers を実装
internal/provider/fake/fake.go エンジンテスト用の in-memory Provider を追加
internal/provider/fake/fake_test.go fake Provider の契約テストを追加
internal/provider/autherr.go OAuth2 refresh 失敗の ErrAuthExpired 正規化を追加
internal/provider/autherr_test.go NormalizeAuthErr のテストを追加
internal/model/model.go 共通モデル(Window/Event/Blocker)とハッシュ/冪等キー生成を追加
internal/model/model_test.go TimeHash/冪等キー/Window.Contains のテストを追加
internal/engine/scheduler.go ポーリング+日次リコンサイルのスケジューラを追加
internal/engine/remove_test.go accounts remove のエンジンテストを追加
internal/engine/errors.go ターゲット認証失効の誤帰属防止(TargetAuthError)を追加
internal/engine/dedupe.go 同一会議の重複抑止・suppressed 昇格ロジックを追加
internal/engine/dedupe_test.go 重複抑止/昇格のテストを追加
internal/engine/classify.go ブロック対象/ウィンドウ判定のヘルパを追加
internal/engine/classify_test.go classify の境界条件テストを追加
internal/config/config.go YAML 読み込み/検証/デフォルト補完、Window/Targets API を追加
internal/config/config_test.go config 検証のテストを追加
internal/auth/tokens.go トークン永続化(0600)と refresh ローテーション追従を追加
internal/auth/tokens_test.go TokenStore/PersistingTokenSource のテストを追加
internal/auth/flow.go ループバック+PKCE / Device Code の OAuth フローを追加
internal/auth/flow_test.go OAuth フローのテストを追加
go.mod 依存関係と Go バージョン指定を追加
docs/superpowers/specs/2026-07-03-calsync-design.md OAuth スコープ等の設計記述を更新
docs/superpowers/plans/2026-07-03-calsync-v1.md 実装計画の Go バージョン等を更新
Dockerfile distroless 向け multi-stage ビルドを追加
docker-compose.yaml compose 例(TZ/volume)を追加
cmd/calsync/main.go ルートCLI、provider/engine 組み立て、孤児検出ヘルパを追加
cmd/calsync/cmd_sync.go sync --once コマンドを追加
cmd/calsync/cmd_status.go status コマンド(読み取り専用)を追加
cmd/calsync/cmd_run.go run デーモン起動を追加
cmd/calsync/cmd_reconcile.go reconcile コマンドを追加
cmd/calsync/cmd_doctor.go doctor 診断コマンドを追加
cmd/calsync/cmd_auth.go auth add/list を追加
cmd/calsync/cmd_accounts.go accounts remove を追加
cmd/calsync/cli_test.go CLI ヘルパ(findOrphanAccounts/renderStatus/doctor)のテストを追加

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +176 to +182
func (c *Client) ListBlockers(ctx context.Context, cal model.CalendarRef, window model.Window) ([]model.BlockerRecord, error) {
q := url.Values{}
q.Set("$filter", fmt.Sprintf(
"singleValueExtendedProperties/Any(ep: ep/id eq '%s' and ep/value ne null)",
originPropertyID))
listURL := c.baseURL + "/me/events?" + encodeQuery(q)

Comment on lines +150 to +165
func (t graphTime) utc() (time.Time, error) {
const layout = "2006-01-02T15:04:05.9999999"
loc := time.UTC
if t.TimeZone != "" && t.TimeZone != "UTC" {
l, err := time.LoadLocation(t.TimeZone)
if err != nil {
return time.Time{}, fmt.Errorf("graph time: unknown timeZone %q: %w", t.TimeZone, err)
}
loc = l
}
parsed, err := time.ParseInLocation(layout, t.DateTime, loc)
if err != nil {
return time.Time{}, fmt.Errorf("graph time: parse %q: %w", t.DateTime, err)
}
return parsed.UTC(), nil
}
Comment thread internal/store/store.go
Comment on lines +134 to +144
func (s *Store) Close() error {
dbErr := s.db.Close()
var unlockErr error
if s.lock != nil {
unlockErr = s.lock.Unlock()
}
if dbErr != nil {
return dbErr
}
return unlockErr
}
Comment thread internal/store/store.go
Comment on lines +54 to +55
CREATE INDEX IF NOT EXISTS idx_events_icaluid ON events (ical_uid, start_utc);

Comment on lines +80 to +92
case resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries:
wait := backoff
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, perr := strconv.Atoi(ra); perr == nil && secs >= 0 {
wait = time.Duration(secs) * time.Second
}
}
resp.Body.Close()
c.sleep(wait)
case resp.StatusCode >= 500 && attempt < maxRetries:
resp.Body.Close()
c.sleep(backoff)
backoff *= 2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants